Skip to content

feat: add Flyway migrations and S3 orphaned object cleanup#269

Merged
johanbriger merged 12 commits into
mainfrom
114-feat-implement-durable-retry-logic-for-failed-s3minio-deletions
Apr 27, 2026
Merged

feat: add Flyway migrations and S3 orphaned object cleanup#269
johanbriger merged 12 commits into
mainfrom
114-feat-implement-durable-retry-logic-for-failed-s3minio-deletions

Conversation

@johanbriger

@johanbriger johanbriger commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Sammanfattning
Denna PR introducerar två stora förbättringar för projektets stabilitet och dataintegritet: Versionshanterad databas (Flyway) och ett självläkande system för fillagring (S3/MinIO).

Ändringar
1. Database Migrations (Flyway)

Vi har gått ifrån att låta Hibernate skapa tabeller till att använda Flyway.

V1__initial_schema.sql: Skapar grundstrukturen för kliniker, användare, djur och journaler.

V2__init_orphaned_table.sql: Skapar tabellen för att hantera radering av filer.

Fördel: Alla utvecklare har nu exakt samma databasstruktur och vi kan säkert rulla ut ändringar till produktion.

2. S3 Orphaned Object Cleanup
För att undvika att "föräldralösa" filer (filer som inte längre har en referens i databasen) blir liggande i MinIO/S3 och tar upp plats, har jag implementerat en asynkron städmekanism.

OrphanedS3Object Entity: En "att-göra-lista" i databasen för filer som ska raderas.

CleanupWorker: En schemalagd bakgrundstjänst som försöker radera filer var 10:e minut.

Retry-logik: Inkluderar felhantering med retryCount och loggning av felmeddelanden (lastError) om S3-tjänsten tillfälligt skulle vara nere.

Testning
Verifierat att Flyway-migreringar körs korrekt vid start.

Testat att manuellt lägga till rader i orphaned_s3_objects och sett att CleanupWorker raderar filen från MinIO och städar upp i tabellen.

Kontrollerat att retry_count ökar om man simulerar anslutningsfel till S3.

Noteringar
spring.jpa.hibernate.ddl-auto är nu satt till validate i dev-profilen för att säkerställa att koden matchar databasschemat.

S3-raderingar sker asynkront för att inte blockera användarens request-flöde.

Stängt av Flyway i testmiljön för att undvika syntaxfel i H2 med: spring.flyway.enabled=false

closes #114

Summary by CodeRabbit

Release Notes

  • New Features

    • Added automatic retry and recovery system for failed file uploads and deletions with configurable maximum retry limits
    • Implemented background cleanup worker that periodically attempts to recover orphaned files
  • Infrastructure

    • Migrated database management to Flyway framework for improved schema versioning and maintainability
    • Enabled Spring scheduling support for background task processing

@johanbriger johanbriger linked an issue Apr 25, 2026 that may be closed by this pull request
6 tasks
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR adds durable retry logic for failed S3/MinIO deletions by introducing an OrphanedS3Object entity and repository, a scheduled background worker that retries failed deletions on a schedule, services to enqueue and process orphans, Flyway database migrations, and updates AttachmentService to capture failed deletions.

Changes

Cohort / File(s) Summary
Database & Flyway Setup
pom.xml, src/main/resources/application-dev.properties, src/test/resources/application-test.properties
Added Spring Boot Flyway starter and PostgreSQL Flyway dependencies; updated dev profile to use Flyway migrations instead of Spring SQL initialization; removed custom resource filtering in Maven build; disabled Flyway in test configuration.
Database Migrations
src/main/resources/db/migration/V1__initial_schema.sql, src/main/resources/db/migration/V2__init_orphaned_table.sql, src/main/resources/db/migration/dev/V3__insert_demo_data.sql
Created initial schema with core veterinary tables; introduced orphaned_s3_objects table with retry counters and last error tracking; seeded demo data for development.
Orphaned S3 Object Persistence
src/main/java/org/example/vet1177/entities/OrphanedS3Object.java, src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
Introduced JPA entity to track failed S3 object deletions with UUID key, retry count, and error details; added repository with custom JPQL query to fetch batches eligible for retry and count permanent failures.
Orphan Enqueue & Process Services
src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java, src/main/java/org/example/vet1177/services/OrphanedS3Processor.java
Added OrphanedS3Enqueuer to upsert orphaned object records with error tracking in independent transactions; added OrphanedS3Processor to attempt retry deletions, increment counters on failure, and log warnings.
Scheduled Cleanup Worker
src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
Introduced scheduled background worker running every 10 minutes to batch-process orphaned S3 objects, invoke processor for each, and emit error alerts when retry count limits are exceeded.
AttachmentService & Application
src/main/java/org/example/vet1177/services/AttachmentService.java, src/main/java/org/example/vet1177/Vet1177Application.java
Enhanced AttachmentService to inject and use OrphanedS3Enqueuer when S3 deletions fail; added exception logging and enqueuing in both upload and delete failure paths; enabled Spring scheduling via @EnableScheduling annotation.
Tests
src/test/java/org/example/vet1177/services/AttachmentServiceTest.java
Updated test suite to mock OrphanedS3Enqueuer and TransactionSynchronizationManager; added assertions for orphan enqueue calls on S3 deletion failures; verified transaction synchronization behavior.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Svc as AttachmentService
    participant S3 as FileStorageService
    participant DB as Database
    participant Enq as OrphanedS3Enqueuer
    
    App->>Svc: deleteAttachment(...)
    activate Svc
    Svc->>DB: registerAfterCommit callback
    Svc->>DB: commit (delete attachment record)
    deactivate Svc
    
    DB-->>Svc: afterCommit triggered
    activate Svc
    Svc->>S3: delete(s3Key)
    S3--xSvc: deletion fails
    Svc->>Enq: enqueue(s3Key, bucket, reason)
    activate Enq
    Enq->>DB: findByS3Key(s3Key)
    alt existing
        Enq->>DB: update lastError, reset lastAttemptAt
    else not found
        Enq->>DB: create new OrphanedS3Object
    end
    Enq->>DB: save()
    deactivate Enq
    deactivate Svc
Loading
sequenceDiagram
    participant Worker as OrphanedS3CleanupWorker
    participant Repo as OrphanedS3ObjectRepository
    participant Proc as OrphanedS3Processor
    participant S3 as FileStorageService
    participant DB as Database
    
    Worker->>Repo: findNextBatchToProcess(maxRetries, pageable)
    Repo-->>Worker: batch of OrphanedS3Objects
    
    loop for each orphan
        Worker->>Proc: processOrphan(orphan, maxRetries)
        activate Proc
        Proc->>S3: delete(s3Key)
        alt success
            S3-->>Proc: deleted
            Proc->>DB: delete OrphanedS3Object
            Proc-->>Worker: log success
        else failure
            S3--xProc: exception
            Proc->>DB: increment retryCount, set lastAttemptAt, update lastError
            Proc->>DB: save()
            Proc-->>Worker: log warning
        end
        deactivate Proc
    end
    
    Worker->>Repo: countByRetryCountGreaterThanEqual(maxRetries)
    Repo-->>Worker: count
    alt count > 0
        Worker->>Worker: log ERROR alert
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • #129 — Modifies the same AttachmentService class with overlapping constructor and method signature changes
  • #112 — Main issue PR that introduces AttachmentService changes that this PR extends with retry logic
  • #119 — Updates pom.xml and application-dev.properties with overlapping resource and flyway configuration changes

Suggested labels

enhancement

Suggested reviewers

  • TatjanaTrajkovic
  • annikaholmqvist94

Poem

🐰✨ Orphans No More ✨🐰

Lost blobs scattered in S3 land,
With patient paws and cunning plan,
A worker hops at every dawn,
To heal the files that were withdrawn.
No orphan left behind, hooray! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the two main features: Flyway migrations and S3 orphaned object cleanup, matching the actual changeset.
Linked Issues check ✅ Passed All requirements from issue #114 are met: OrphanedS3Object entity with required fields, idempotent repository, AttachmentService enqueuing failures, scheduled cleanup worker with retry logic and max-retry cap, and test coverage.
Out of Scope Changes check ✅ Passed Changes are scoped to implementing durable S3 retry logic and Flyway migrations; updates to pom.xml (Flyway deps), application configs, and demo data relocation all directly support the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 114-feat-implement-durable-retry-logic-for-failed-s3minio-deletions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/test/java/org/example/vet1177/services/AttachmentServiceTest.java (1)

51-78: ⚠️ Potential issue | 🟡 Minor

Wiring is correct, but add test coverage for S3 deletion failures.

The PR introduces persistence of OrphanedS3Object records when S3 delete fails. The service uses TransactionSynchronizationManager.afterCommit() to defer the delete operation (lines 132–139 in the service), which provides the asynchronous behavior mentioned in the PR. However, the existing delete tests only cover the happy path. Add test(s) that:

  • Mock fileStorageService.delete() to throw an exception
  • Verify that orphanedS3ObjectRepository.save() is called with an OrphanedS3Object containing the expected s3Key and s3Bucket
  • Ensure the delete operation does not propagate the exception (graceful handling)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/example/vet1177/services/AttachmentServiceTest.java` around
lines 51 - 78, Add a test in AttachmentServiceTest that simulates an S3 deletion
failure by mocking fileStorageService.delete(...) to throw, then call
AttachmentService.deleteAttachment(recordId, attachmentId) (same method used by
existing delete tests); assert that the exception is not propagated (call
completes normally) and verify orphanedS3ObjectRepository.save(...) was called
with an OrphanedS3Object whose s3Key equals the attachment's S3 key and s3Bucket
equals props.getBucketName(); ensure the test runs after setUp so
AttachmentService, attachment and record fixtures are used and rely on
TransactionSynchronizationManager.afterCommit() behavior to trigger the orphan
save.
src/main/java/org/example/vet1177/services/AttachmentService.java (1)

130-140: ⚠️ Potential issue | 🟡 Minor

Add null check and fallback for s3Bucket, plus error handling around orphan save.

Two gaps in the afterCommit hook:

  1. Null safety for s3Bucket: Although Attachment.s3Bucket is nullable = false, legacy rows created before this constraint was added could have null values. When orphanedS3ObjectRepository.save() is called at line 139, a null value will violate the OrphanedS3Object.s3Bucket constraint and throw an exception that Spring silently swallows into a TransactionSystemException. Add a null check and fall back to bucketName when null.

  2. Error handling for save() itself: If orphanedS3ObjectRepository.save(orphan) fails (line 139), the exception is not explicitly logged. Wrap it in try-catch with a clear log.error() statement; the same applies to line 147 in the non-transactional path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/AttachmentService.java` around
lines 130 - 140, Handle null s3Bucket and errors when persisting orphan records:
in the afterCommit() hook (and the non-transactional branch that also calls
orphanedS3ObjectRepository.save), check if s3Bucket is null and fall back to
bucketName before constructing the OrphanedS3Object; then wrap
orphanedS3ObjectRepository.save(orphan) in its own try-catch and log.error the
exception with a clear message if saving fails (include the s3Key and chosen
bucket value). Ensure you reference the same OrphanedS3Object creation path used
when fileStorageService.delete(...) fails and apply identical error logging in
both places.
🧹 Nitpick comments (4)
src/main/java/org/example/vet1177/entities/OrphanedS3Object.java (1)

22-40: Optional cleanup: redundant initialization and @PrePersist for createdAt.

A few small refinements:

  • createdAt = Instant.now() is set both in the field initializer (line 22) and in the convenience constructor (line 38) — drop the duplicate.
  • Same for retryCount = 0 in the constructor (line 39) — already the field default.
  • Prefer @PrePersist over a field initializer for createdAt. As written, createdAt is fixed at object construction time, so an entity built and then held in memory before save() will record a timestamp earlier than the actual insert. Not a correctness bug for this orphan-tracking flow, but @PrePersist is the idiomatic JPA pattern.
♻️ Proposed refactor
     `@Column`(name = "created_at", nullable = false, updatable = false)
-    private Instant createdAt = Instant.now();
+    private Instant createdAt;
@@
     public OrphanedS3Object(String s3Key, String s3Bucket) {
         this.s3Key = s3Key;
         this.s3Bucket = s3Bucket;
-        this.createdAt = Instant.now();
-        this.retryCount = 0;
+    }
+
+    `@PrePersist`
+    void onCreate() {
+        if (this.createdAt == null) {
+            this.createdAt = Instant.now();
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/entities/OrphanedS3Object.java` around
lines 22 - 40, Remove the redundant initializations in the OrphanedS3Object
class: delete the field initializer "createdAt = Instant.now()" and the
constructor assignments "this.createdAt = Instant.now()" and "this.retryCount =
0" in the OrphanedS3Object(String s3Key, String s3Bucket) constructor (keep the
field declaration for retryCount with default 0). Add a `@PrePersist` lifecycle
method (e.g., prePersist()) that sets createdAt = Instant.now() if createdAt is
null so the timestamp is set at persist time rather than construction.
src/main/resources/db/migration/V2__init_orphaned_table.sql (1)

2-14: Minor: align idempotency style with V1.

V1 uses CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS; V2 doesn't. For Flyway-versioned migrations either style is fine (each version runs once), but consistency reduces surprise during local re-bootstraps and flyway repair.

-CREATE TABLE orphaned_s3_objects (
+CREATE TABLE IF NOT EXISTS orphaned_s3_objects (
@@
-CREATE INDEX idx_orphaned_s3_retry ON orphaned_s3_objects (retry_count, last_attempt_at);
+CREATE INDEX IF NOT EXISTS idx_orphaned_s3_retry ON orphaned_s3_objects (retry_count, last_attempt_at);

Also worth considering: the secondary index (retry_count, last_attempt_at) matches the repository's findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc query — make sure the ordering of the worker's filter (retry_count < N) and ORDER BY are kept in sync if you ever change the column order here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/db/migration/V2__init_orphaned_table.sql` around lines 2 -
14, The migration lacks idempotent CREATE ... IF NOT EXISTS style used in V1;
update the V2 SQL to use "CREATE TABLE IF NOT EXISTS orphaned_s3_objects" and
"CREATE INDEX IF NOT EXISTS idx_orphaned_s3_retry" so local reboots and flyway
repair behave consistently, and while editing confirm the index column order on
idx_orphaned_s3_retry (retry_count, last_attempt_at) remains compatible with the
repository query findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc (i.e.,
retry_count as the first column and last_attempt_at as the second) to preserve
query performance and ordering semantics.
src/main/resources/db/migration/V1__initial_schema.sql (1)

69-87: Remove the redundant ALTER TABLE ... ADD COLUMN IF NOT EXISTS statements.

comment_type (line 64) and description (line 76) are already declared inline in the same CREATE TABLE block above. The trailing ALTER TABLE statements at lines 69 and 87 are dead in a fresh V1 migration — CREATE TABLE IF NOT EXISTS either creates the table with these columns or finds an existing table; in both cases the ALTER is either a no-op or, if the table existed without the column, an unrelated legacy concern that does not belong in the initial schema.

The comment at lines 84–86 acknowledges this is for "legacy databases", but Flyway's migration history is what tracks that — those one-off fixups should live in their own versioned migration (e.g. V1.1__...), not wedged into V1.

♻️ Proposed cleanup
-ALTER TABLE comment ADD COLUMN IF NOT EXISTS comment_type VARCHAR(32) NOT NULL DEFAULT 'OWNER_MESSAGE';
-
 CREATE TABLE IF NOT EXISTS attachment (
@@
 );
-
--- Migration för befintliga databaser som skapades innan description-kolumnen lades till.
--- CREATE TABLE IF NOT EXISTS ovan är no-op om tabellen redan finns, så kolumnen måste
--- läggas till explicit. Samma mönster som comment_type ovan.
-ALTER TABLE attachment ADD COLUMN IF NOT EXISTS description VARCHAR(500);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/db/migration/V1__initial_schema.sql` around lines 69 - 87,
The ALTER TABLE ADD COLUMN IF NOT EXISTS statements for comment_type and
description are redundant because comment_type is already declared in the
comment CREATE TABLE and description is declared in the attachment CREATE TABLE
in this V1 migration; remove the two ALTER TABLE lines that reference
comment_type and attachment.description (the ALTER TABLE comment ADD COLUMN IF
NOT EXISTS comment_type ... and ALTER TABLE attachment ADD COLUMN IF NOT EXISTS
description ...) from V1__initial_schema.sql, and if you need to support legacy
databases that may lack those columns, create a new separate versioned migration
(e.g. V1_1__add_missing_columns.sql) that performs those ALTER TABLE operations
instead.
src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java (1)

22-22: Consider what happens after MAX_RETRIES is reached.

Once retryCount hits 10 the row is silently ignored by the query forever — no alert, no metric, no dead-letter. Operators have no signal that data in S3 is permanently orphaned. Worth either emitting a counter/log when a row is skipped because it has exhausted retries, or moving such rows to a separate "dead" table/state for manual review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java` at
line 22, The code currently drops rows once retryCount reaches MAX_RETRIES (10)
with no signal; update OrphanedS3CleanupWorker to handle exhausted retries by
detecting when retryCount >= MAX_RETRIES and then (a) emit a clear log via
process/logger (include row id and reason) and increment a dedicated
metric/counter (e.g., orphaned_s3_deadletter_count), and/or (b) move the row to
a dead-letter state/table (insert into an OrphanedS3DeadLetter table or set a
status column like 'DEAD') and remove it from the retry set so operators can
review; ensure changes are applied where retry logic updates retryCount and
retries are evaluated (refer to MAX_RETRIES, retryCount, and the method handling
the cleanup loop in OrphanedS3CleanupWorker).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java`:
- Line 12: The derived query method
findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc in
OrphanedS3ObjectRepository orders by last_attempt_at which is nullable and will
place NULLs last in PostgreSQL, starving new rows; fix by replacing that
ordering with a non-null column (e.g., order by created_at), or replace the
method with an explicit `@Query` that uses "ORDER BY last_attempt_at ASC NULLS
FIRST", or ensure last_attempt_at is initialized to created_at on insert so
NULLs do not occur; update the repository method signature and/or add the `@Query`
accordingly and adjust any insert logic that sets last_attempt_at.

In `@src/main/java/org/example/vet1177/services/AttachmentService.java`:
- Around line 143-149: The else branch in deleteAttachment is unreachable and
unsafe — don't attempt to delete S3 objects outside the transaction; remove the
else block that calls fileStorageService.delete(s3Key) and
orphanedS3ObjectRepository.save(...). Instead keep the transaction-aware path
that registers a TransactionSynchronization (using
TransactionSynchronizationManager.isSynchronizationActive() and
afterCommit/afterCompletion) to delete the S3 object with
fileStorageService.delete(s3Key) and only record
orphanedS3ObjectRepository.save(...) from within the synchronization if deletion
fails; if you prefer a fail-fast behavior, drop any fallback and let the method
fail loudly when called without an active transaction.
- Around line 103-112: The orphan enqueue is being saved inside the original
`@Transactional` uploadAttachment transaction so it gets rolled back; extract the
save into a new Spring bean (e.g. class OrphanedS3Enqueuer) with a method
enqueue(String s3Key, String bucket, String reason) annotated
`@Transactional`(propagation = Propagation.REQUIRES_NEW) which builds the
OrphanedS3Object (truncate reason to 1024, set lastAttemptAt) and
repo.save(...); replace direct calls to orphanedS3ObjectRepository.save(...) in
AttachmentService (both the catch block in uploadAttachment and the else branch
in deleteAttachment) with calls to OrphanedS3Enqueuer.enqueue(...) to ensure the
orphan record is persisted outside the failing transaction (note: do not try to
call a `@Transactional` method on this from the same class via self-invocation).

In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java`:
- Line 38: The repository query used by OrphanedS3CleanupWorker.retryDeletions
(findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc) returns plain rows that
concurrent workers can read; change the repository method to acquire row-level
locks (e.g., add `@Lock`(LockModeType.PESSIMISTIC_WRITE) and a JPA query hint for
SKIP LOCKED / lock timeout or replace with a native query using FOR UPDATE SKIP
LOCKED) so only one worker processes each OrphanedS3Object at a time;
alternatively, if you prefer optimistic handling add `@Version` to the
OrphanedS3Object entity to surface lost-update races via
OptimisticLockException.
- Line 38: The current query used by OrphanedS3CleanupWorker
(repository.findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc with
MAX_RETRIES) lets rows with null lastAttemptAt sort after non-null values,
starving newly inserted orphans; fix this by either initializing lastAttemptAt
in the OrphanedS3Object constructor (set lastAttemptAt = createdAt or
Instant.now() in the constructor where fields are set) so new rows have a
deterministic timestamp, or change the repository query to an explicit `@Query`
that orders by lastAttemptAt NULLS FIRST (e.g., "ORDER BY last_attempt_at ASC
NULLS FIRST") to ensure new/null-lastAttemptAt rows are prioritized in the
top-20 selection.
- Around line 34-66: The batch method retryDeletions() currently has
`@Transactional` for the whole loop; remove that annotation from retryDeletions()
and extract the per-item logic into a new bean method (e.g.
processOrphanOrRetry(OrphanedS3Object orphan)) annotated
`@Transactional`(propagation = Propagation.REQUIRES_NEW) so each orphan's work
(call fileStorageService.delete(orphan.getS3Key()), repository.delete(orphan) on
success, and orphan.setRetryCount/... + repository.save(orphan) on failure) is
committed per-item; keep the `@Scheduled` on retryDeletions() in
OrphanedS3CleanupWorker, have it fetch the pending list with
repository.findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc(MAX_RETRIES) and
call the new REQUIRES_NEW method for each orphan, and ensure imports for
Propagation are added.
- Around line 62-63: The warn log in OrphanedS3CleanupWorker that currently
calls log.warn("Retry failed for S3 object {}. Attempt {}/{}",
orphan.getS3Key(), orphan.getRetryCount(), MAX_RETRIES) drops the exception;
update the call to pass the Throwable (the caught exception variable, e) as the
trailing argument so SLF4J will print the stack trace and underlying cause (keep
the existing message and placeholders and still set orphan.setLastError(...) as
before). Ensure you reference the same variables (orphan.getS3Key(),
orphan.getRetryCount(), MAX_RETRIES, lastError) and only append the exception
object to the log call.

In `@src/main/resources/application-dev.properties`:
- Around line 16-17: The application-dev.properties currently forces Spring to
run schema/data SQL after startup (spring.sql.init.mode=always) which can
conflict with Flyway-managed migrations; update application-dev.properties to
stop Spring SQL initialization by removing the spring.sql.init.mode property or
set spring.sql.init.mode=never so Flyway alone manages schema/seed changes (also
ensure spring.jpa.hibernate.ddl-auto remains set to validate to prevent
Hibernate from altering schema).

In `@src/main/resources/db/migration/V1__initial_schema.sql`:
- Around line 1-9: The Flyway migration V1__initial_schema.sql uses
PostgreSQL-only syntax (gen_random_uuid(), TIMESTAMPTZ) which breaks H2 in CI;
either switch CI to run migrations against a real PostgreSQL test instance
(e.g., Testcontainers) so V1__initial_schema.sql can stay unchanged, or make the
migration H2-compatible by changing the clinic table defaults/types: replace
gen_random_uuid() with RANDOM_UUID() (or remove DB default and set UUID in JPA)
and replace TIMESTAMPTZ with a H2-compatible type such as TIMESTAMP WITH TIME
ZONE (or TIMESTAMP) and ensure the H2 JDBC URL uses MODE=PostgreSQL; update
V1__initial_schema.sql accordingly and keep the table name clinic and its
columns (id, created_at, updated_at) consistent.

---

Outside diff comments:
In `@src/main/java/org/example/vet1177/services/AttachmentService.java`:
- Around line 130-140: Handle null s3Bucket and errors when persisting orphan
records: in the afterCommit() hook (and the non-transactional branch that also
calls orphanedS3ObjectRepository.save), check if s3Bucket is null and fall back
to bucketName before constructing the OrphanedS3Object; then wrap
orphanedS3ObjectRepository.save(orphan) in its own try-catch and log.error the
exception with a clear message if saving fails (include the s3Key and chosen
bucket value). Ensure you reference the same OrphanedS3Object creation path used
when fileStorageService.delete(...) fails and apply identical error logging in
both places.

In `@src/test/java/org/example/vet1177/services/AttachmentServiceTest.java`:
- Around line 51-78: Add a test in AttachmentServiceTest that simulates an S3
deletion failure by mocking fileStorageService.delete(...) to throw, then call
AttachmentService.deleteAttachment(recordId, attachmentId) (same method used by
existing delete tests); assert that the exception is not propagated (call
completes normally) and verify orphanedS3ObjectRepository.save(...) was called
with an OrphanedS3Object whose s3Key equals the attachment's S3 key and s3Bucket
equals props.getBucketName(); ensure the test runs after setUp so
AttachmentService, attachment and record fixtures are used and rely on
TransactionSynchronizationManager.afterCommit() behavior to trigger the orphan
save.

---

Nitpick comments:
In `@src/main/java/org/example/vet1177/entities/OrphanedS3Object.java`:
- Around line 22-40: Remove the redundant initializations in the
OrphanedS3Object class: delete the field initializer "createdAt = Instant.now()"
and the constructor assignments "this.createdAt = Instant.now()" and
"this.retryCount = 0" in the OrphanedS3Object(String s3Key, String s3Bucket)
constructor (keep the field declaration for retryCount with default 0). Add a
`@PrePersist` lifecycle method (e.g., prePersist()) that sets createdAt =
Instant.now() if createdAt is null so the timestamp is set at persist time
rather than construction.

In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java`:
- Line 22: The code currently drops rows once retryCount reaches MAX_RETRIES
(10) with no signal; update OrphanedS3CleanupWorker to handle exhausted retries
by detecting when retryCount >= MAX_RETRIES and then (a) emit a clear log via
process/logger (include row id and reason) and increment a dedicated
metric/counter (e.g., orphaned_s3_deadletter_count), and/or (b) move the row to
a dead-letter state/table (insert into an OrphanedS3DeadLetter table or set a
status column like 'DEAD') and remove it from the retry set so operators can
review; ensure changes are applied where retry logic updates retryCount and
retries are evaluated (refer to MAX_RETRIES, retryCount, and the method handling
the cleanup loop in OrphanedS3CleanupWorker).

In `@src/main/resources/db/migration/V1__initial_schema.sql`:
- Around line 69-87: The ALTER TABLE ADD COLUMN IF NOT EXISTS statements for
comment_type and description are redundant because comment_type is already
declared in the comment CREATE TABLE and description is declared in the
attachment CREATE TABLE in this V1 migration; remove the two ALTER TABLE lines
that reference comment_type and attachment.description (the ALTER TABLE comment
ADD COLUMN IF NOT EXISTS comment_type ... and ALTER TABLE attachment ADD COLUMN
IF NOT EXISTS description ...) from V1__initial_schema.sql, and if you need to
support legacy databases that may lack those columns, create a new separate
versioned migration (e.g. V1_1__add_missing_columns.sql) that performs those
ALTER TABLE operations instead.

In `@src/main/resources/db/migration/V2__init_orphaned_table.sql`:
- Around line 2-14: The migration lacks idempotent CREATE ... IF NOT EXISTS
style used in V1; update the V2 SQL to use "CREATE TABLE IF NOT EXISTS
orphaned_s3_objects" and "CREATE INDEX IF NOT EXISTS idx_orphaned_s3_retry" so
local reboots and flyway repair behave consistently, and while editing confirm
the index column order on idx_orphaned_s3_retry (retry_count, last_attempt_at)
remains compatible with the repository query
findTop20ByRetryCountLessThanOrderByLastAttemptAtAsc (i.e., retry_count as the
first column and last_attempt_at as the second) to preserve query performance
and ordering semantics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 59bd74ce-d6f9-4ddd-875d-e5bf2db13713

📥 Commits

Reviewing files that changed from the base of the PR and between d016ed5 and 5e44b5a.

📒 Files selected for processing (10)
  • pom.xml
  • src/main/java/org/example/vet1177/Vet1177Application.java
  • src/main/java/org/example/vet1177/entities/OrphanedS3Object.java
  • src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
  • src/main/java/org/example/vet1177/services/AttachmentService.java
  • src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
  • src/main/resources/application-dev.properties
  • src/main/resources/db/migration/V1__initial_schema.sql
  • src/main/resources/db/migration/V2__init_orphaned_table.sql
  • src/test/java/org/example/vet1177/services/AttachmentServiceTest.java

Comment thread src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java Outdated
Comment thread src/main/java/org/example/vet1177/services/AttachmentService.java Outdated
Comment thread src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java Outdated
Comment thread src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java Outdated
Comment thread src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java Outdated
Comment thread src/main/resources/application-dev.properties Outdated
Comment thread src/main/resources/db/migration/V1__initial_schema.sql
  persist even if main transactions roll back.
- Updated AttachmentService to use the new enqueuer and refined
  post-commit deletion logic using TransactionSynchronization.
- Optimized cleanup queue with 'NULLS FIRST' ordering to prevent
  retries from starving new deletion tasks.
- Refactored AttachmentServiceTest to reflect new service architecture
  and verified error-handling edge cases.
- Remove unsafe fallback for S3 deletion outside of active transactions.
- Refactor deleteAttachment to rely solely on TransactionSynchronization.
- Implement fail-fast behavior by requiring an active transaction context.
- Update AttachmentServiceTest to verify post-commit synchronization
  using Mockito static mocks and ArgumentCaptors.
- Move demo data from data.sql to V3__insert_demo_data.sql.
- Disable spring.sql.init.mode to let Flyway handle all data seeding.
- Set spring.jpa.hibernate.ddl-auto=none to prevent validation errors
  during Flyway initialization (missing activity_log table).
- Clean up V3 script to include explicit timestamps and default values.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
src/test/java/org/example/vet1177/services/AttachmentServiceTest.java (1)

270-295: Nit: inconsistent indentation inside the try-with-resources blocks.

In delete_shouldDeleteFromRepository (lines 272-281) and delete_shouldCallPolicyCanDelete (lines 285-294), only the new mockedStatic lines are indented relative to the try block; the existing when(...), action, and verify(...) lines remain at the original indentation level. The block in delete_shouldCallFileStorageDelete (lines 299-314) is consistently indented and is much easier to read — recommend re-indenting the other two for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/org/example/vet1177/services/AttachmentServiceTest.java` around
lines 270 - 295, Re-indent the contents of the two test methods
delete_shouldDeleteFromRepository and delete_shouldCallPolicyCanDelete so all
statements inside the try-with-resources block using mockedStatic are
consistently indented (including the mockedStatic.when(...),
when(attachmentRepository.findById(...)),
attachmentService.deleteAttachment(...),
verify(attachmentRepository).delete(...),
verify(attachmentPolicy).canDelete(...), and mockedStatic.verify(...) lines) to
match the style used in delete_shouldCallFileStorageDelete; locate the try(...)
using mockStatic(TransactionSynchronizationManager.class) and adjust the inner
block indentation accordingly.
src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java (1)

28-29: Nit: redundant setLastAttemptAt(null).

lastAttemptAt has no default initializer in OrphanedS3Object, so it is already null after construction. The explicit setter call (and the extra blank line at line 28) can be removed without behavior change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java` around
lines 28 - 29, Remove the redundant explicit null assignment to lastAttemptAt in
OrphanedS3Enqueuer: delete the call orphan.setLastAttemptAt(null) (and the extra
blank line) since OrphanedS3Object.lastAttemptAt is null by default after
construction; this keeps behavior unchanged and cleans up the code.
src/main/java/org/example/vet1177/services/AttachmentService.java (1)

124-136: Always-register-afterCommit flow LGTM, but indentation is leftover from the old if/else.

Addresses the prior concern about the unreachable else branch. registerSynchronization is now always called, which is correct since deleteAttachment is @Transactional and synchronization is guaranteed to be active. Stack trace at line 131 also now propagates e properly.

Nit: line 124 keeps an extra level of indentation (8 extra spaces) inherited from the removed if (...isSynchronizationActive()) branch. Worth re-aligning to the method's normal block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/AttachmentService.java` around
lines 124 - 136, The block that calls
TransactionSynchronizationManager.registerSynchronization(...) inside
AttachmentService (the anonymous TransactionSynchronization.afterCommit
implementation that calls fileStorageService.delete and
orphanedS3Enqueuer.enqueue) has leftover extra indentation from a removed
if/else; reformat the registerSynchronization call and its anonymous class to
match the method's normal indentation level (remove the 8-space overindent) so
the block aligns with the surrounding method code and visual style.
src/main/java/org/example/vet1177/services/OrphanedS3Processor.java (1)

26-51: External S3 call held inside the DB transaction.

processOrphan opens a REQUIRES_NEW transaction on entry and only commits after fileStorageService.delete(...) returns. That means the JDBC connection is held for the entire duration of the S3/MinIO RTT — which is exactly the failure mode this worker exists to handle (slow/unreachable S3). Under load (20 orphans × slow S3), this can pin connections from the pool while doing zero useful DB work.

Recommended pattern: do the S3 call outside the transaction, then open a short-lived transaction to either delete the row or persist the failure metadata.

♻️ Sketch of the refactor
// in OrphanedS3CleanupWorker, no `@Transactional` on the loop
for (OrphanedS3Object orphan : pending) {
    boolean deleted;
    String errorMessage = null;
    try {
        fileStorageService.delete(orphan.getS3Key());
        deleted = true;
    } catch (Exception e) {
        deleted = false;
        errorMessage = e.getMessage();
        log.warn("Retry failed for S3 object {}", orphan.getS3Key(), e);
    }
    processor.recordOutcome(orphan.getId(), deleted, errorMessage, MAX_RETRIES);
}

// in OrphanedS3Processor
`@Transactional`(propagation = Propagation.REQUIRES_NEW)
public void recordOutcome(UUID id, boolean deleted, String error, int maxRetries) {
    // re-load by id, then either delete or update retryCount/lastAttemptAt/lastError
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/OrphanedS3Processor.java` around
lines 26 - 51, processOrphan currently performs the external call
fileStorageService.delete(...) inside a REQUIRES_NEW transaction which holds a
DB connection during S3/MinIO RTT; move the S3 call out of the transaction and
convert processOrphan into two steps: perform
fileStorageService.delete(orphan.getS3Key()) outside any transaction, capture
success/exception and error message, then call a new transactional method (e.g.
recordOutcome(UUID id, boolean deleted, String error, int maxRetries) on
OrphanedS3Processor) annotated with `@Transactional`(propagation =
Propagation.REQUIRES_NEW) that reloads the OrphanedS3Object by id and either
repository.delete(orphan) on success or updates retryCount, lastAttemptAt,
lastError (truncate to 1024 chars) and repository.save(orphan) on failure;
update the caller (OrphanedS3CleanupWorker loop) to call the delete first and
then processor.recordOutcome(...) with the result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/org/example/vet1177/services/AttachmentService.java`:
- Around line 100-109: The catch block loses stack traces and uses a hardcoded
enqueue reason; update the log.error calls to pass the exception objects so
SLF4J includes stack traces (e.g., log.error("Database persistence failed for S3
key: {}. Triggering cleanup.", s3Key, e) and log.error("Upload cleanup failed.
Enqueuing {} for background retry.", s3Key, deleteEx)), and change the
orphanedS3Enqueuer.enqueue call to include a descriptive reason built from the
actual exceptions (e.g., combine deleteEx.getMessage() and e.getMessage()/class
names) instead of the literal "Upload transaction failed" so lastError contains
the real cause; keep the RuntimeException thrown but preserve the original
exception as its cause.

In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java`:
- Around line 31-44: The worker currently ignores rows with retryCount >=
MAX_RETRIES (found via repository.findNextBatchToProcess) so permanently-failed
orphans never get surfaced; add a repository method (e.g.,
OrphanedS3ObjectRepository.countByRetryCountGreaterThanEqual(int retries)) and,
after processing the pending batch in OrphanedS3CleanupWorker.retryDeletions (or
the loop that calls processor.processOrphan(orphan, MAX_RETRIES)), query that
count and emit an ERROR-level log (or metric) like "ALERT: {} orphaned S3
objects have exceeded MAX_RETRIES={}" when the count > 0 so operators are
alerted to stuck records.

In `@src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java`:
- Around line 25-40: The enqueue method can throw
DataIntegrityViolationException on duplicate s3_key; make it idempotent by
implementing a find-or-create flow: add Optional<OrphanedS3Object>
findByS3Key(String s3Key) to OrphanedS3ObjectRepository, then change
OrphanedS3Enqueuer.enqueue to call repository.findByS3Key(s3Key) and if present
update that entity's lastError (with the same truncation logic) and
lastAttemptAt=null and save, otherwise create and save a new OrphanedS3Object;
this avoids unique-constraint inserts and ensures upsert semantics without
propagating constraint exceptions from repository.save.

In `@src/main/resources/db/migration/V3__insert_demo_data.sql`:
- Around line 10-13: The demo seed inserts currently reuse the same bcrypt hash
('$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy') for all users
(including 'Sara Admin' / role 'ADMIN'); replace with distinct bcrypt hashes per
user (generate unique salts/hashes) and remove or disable privileged accounts in
seed data by either omitting the 'Sara Admin' ADMIN row or setting its
is_active/active flag to false (and similarly set is_active=false for non-OWNER
roles like 'VET'/'ADMIN'), and if your schema supports it set a
password_must_change flag for seeded accounts; document any cleartext dev
passwords only in a non-deployable developer README rather than in the
migration.
- Around line 1-32: Demo seed migration V3__insert_demo_data.sql is currently in
the default Flyway location and will run in production; move it to a dev-only
location and update dev profile Flyway locations to avoid executing in prod.
Move V3__insert_demo_data.sql into a dev migrations folder (e.g.,
classpath:db/migration/dev), add or update application-dev.properties to set
spring.flyway.locations=classpath:db/migration,classpath:db/migration/dev so
Flyway picks up dev seeds only when the dev profile is active, and ensure
production config does not include the dev path; also remove or rotate any
deterministic/demo credentials in the seed (e.g., the ADMIN sara@admin.se bcrypt
hash) to avoid shipping known passwords.

In `@src/test/java/org/example/vet1177/services/AttachmentServiceTest.java`:
- Around line 297-315: Add a test that exercises the post-commit failure path in
AttachmentService.deleteAttachment: mock
TransactionSynchronizationManager.isSynchronizationActive() to true and
attachmentRepository.findById(...) to return the attachment, stub
fileStorageService.delete(s3Key) to throw (e.g., RuntimeException), invoke
attachmentService.deleteAttachment(vetUser, attachmentId), capture the
TransactionSynchronization registered via
TransactionSynchronizationManager.registerSynchronization(...) (use
ArgumentCaptor<TransactionSynchronization>), call captured.afterCommit(), and
verify that orphanedS3Enqueuer.enqueue(attachment.getS3Key(), the expected
bucket name, any retry metadata) is invoked; ensure you reference the same
attachmentId/attachment used elsewhere in the test class and the real bucket
string used by AttachmentService.

---

Nitpick comments:
In `@src/main/java/org/example/vet1177/services/AttachmentService.java`:
- Around line 124-136: The block that calls
TransactionSynchronizationManager.registerSynchronization(...) inside
AttachmentService (the anonymous TransactionSynchronization.afterCommit
implementation that calls fileStorageService.delete and
orphanedS3Enqueuer.enqueue) has leftover extra indentation from a removed
if/else; reformat the registerSynchronization call and its anonymous class to
match the method's normal indentation level (remove the 8-space overindent) so
the block aligns with the surrounding method code and visual style.

In `@src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java`:
- Around line 28-29: Remove the redundant explicit null assignment to
lastAttemptAt in OrphanedS3Enqueuer: delete the call
orphan.setLastAttemptAt(null) (and the extra blank line) since
OrphanedS3Object.lastAttemptAt is null by default after construction; this keeps
behavior unchanged and cleans up the code.

In `@src/main/java/org/example/vet1177/services/OrphanedS3Processor.java`:
- Around line 26-51: processOrphan currently performs the external call
fileStorageService.delete(...) inside a REQUIRES_NEW transaction which holds a
DB connection during S3/MinIO RTT; move the S3 call out of the transaction and
convert processOrphan into two steps: perform
fileStorageService.delete(orphan.getS3Key()) outside any transaction, capture
success/exception and error message, then call a new transactional method (e.g.
recordOutcome(UUID id, boolean deleted, String error, int maxRetries) on
OrphanedS3Processor) annotated with `@Transactional`(propagation =
Propagation.REQUIRES_NEW) that reloads the OrphanedS3Object by id and either
repository.delete(orphan) on success or updates retryCount, lastAttemptAt,
lastError (truncate to 1024 chars) and repository.save(orphan) on failure;
update the caller (OrphanedS3CleanupWorker loop) to call the delete first and
then processor.recordOutcome(...) with the result.

In `@src/test/java/org/example/vet1177/services/AttachmentServiceTest.java`:
- Around line 270-295: Re-indent the contents of the two test methods
delete_shouldDeleteFromRepository and delete_shouldCallPolicyCanDelete so all
statements inside the try-with-resources block using mockedStatic are
consistently indented (including the mockedStatic.when(...),
when(attachmentRepository.findById(...)),
attachmentService.deleteAttachment(...),
verify(attachmentRepository).delete(...),
verify(attachmentPolicy).canDelete(...), and mockedStatic.verify(...) lines) to
match the style used in delete_shouldCallFileStorageDelete; locate the try(...)
using mockStatic(TransactionSynchronizationManager.class) and adjust the inner
block indentation accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fa3c6e98-d151-43c9-9e58-e3fdf2d1ad86

📥 Commits

Reviewing files that changed from the base of the PR and between 5e44b5a and 72c334c.

📒 Files selected for processing (9)
  • src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
  • src/main/java/org/example/vet1177/services/AttachmentService.java
  • src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
  • src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
  • src/main/java/org/example/vet1177/services/OrphanedS3Processor.java
  • src/main/resources/application-dev.properties
  • src/main/resources/db/migration/V3__insert_demo_data.sql
  • src/test/java/org/example/vet1177/services/AttachmentServiceTest.java
  • src/test/resources/application-test.properties
✅ Files skipped from review due to trivial changes (1)
  • src/test/resources/application-test.properties
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/resources/application-dev.properties

Comment thread src/main/java/org/example/vet1177/services/AttachmentService.java
Comment thread src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
Comment thread src/main/resources/db/migration/dev/V3__insert_demo_data.sql
Comment thread src/main/resources/db/migration/dev/V3__insert_demo_data.sql

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/main/resources/db/migration/dev/V3__insert_demo_data.sql (1)

10-13: ⚠️ Potential issue | 🟠 Major

Shared bcrypt hash across all four demo users (including ADMIN) — still unchanged.

Even scoped to the dev profile, all four rows reuse the identical hash $2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy, and the sara@admin.se ADMIN account is seeded in the same batch. Once the cleartext is known to anyone on the team it unlocks every demo identity at once, and shared CI/dev environments inherit the same credential surface. Consider: distinct hashes per row, omitting the ADMIN account from seed data (provision out-of-band), and/or setting is_active = false on VET/ADMIN rows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/db/migration/dev/V3__insert_demo_data.sql` around lines 10
- 13, The seeded demo users in V3__insert_demo_data.sql all share the same
bcrypt hash ('$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy')
(rows for 'anna@test.se', 'lars@test.se', 'erik@klinik.se', 'sara@admin.se'),
which leaks a single credential for all accounts; fix by generating and using
unique bcrypt hashes per row (replacing the shared hash for 'anna@test.se',
'lars@test.se', 'erik@klinik.se', and 'sara@admin.se'), and either remove the
ADMIN seed row for 'sara@admin.se' or set its is_active = false (and optionally
set is_active = false for the 'VET' row 'erik@klinik.se') so privileged accounts
are not immediately usable in dev/CI.
🧹 Nitpick comments (1)
src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java (1)

46-51: ERROR log will fire every 10 minutes for the same stuck rows — alert fatigue.

countByRetryCountGreaterThanEqual(MAX_RETRIES) runs on every tick, so as soon as one orphan permanently fails, the same ERROR line repeats every 10 minutes forever (until somebody manually intervenes). That's noisy on log/alerting backends that page on ERROR rate, and the message is identical so dedup may suppress real new failures.

Consider one of:

  • Emit a gauge metric (e.g. Micrometer MeterRegistry.gauge("s3.orphaned.permanent_failures", …)) instead of (or in addition to) an ERROR log, so dashboards/alerts handle thresholding.
  • Log at WARN and let an alerting rule on the metric drive paging.
  • Track the last-reported count and only re-log when it changes.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java`
around lines 46 - 51, The ERROR log currently emitted every run using
repository.countByRetryCountGreaterThanEqual(MAX_RETRIES) causes alert fatigue;
fix by recording a class-level long lastReportedPermanentFailures (default 0),
update a Micrometer gauge via
MeterRegistry.gauge("s3.orphaned.permanent_failures", ...) with the current
count, and only call log.warn/log.error (use WARN or ERROR per team policy) when
permanentFailures != lastReportedPermanentFailures or when permanentFailures
increases; update lastReportedPermanentFailures after logging so identical
repeated counts do not re-log.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/main/resources/db/migration/dev/V3__insert_demo_data.sql`:
- Around line 10-13: The seeded demo users in V3__insert_demo_data.sql all share
the same bcrypt hash
('$2a$10$mxL7BIFi9S2jdA867vmuDeRe4SZ/krIiHsCzvOdh/3j5nviJ0G2xy') (rows for
'anna@test.se', 'lars@test.se', 'erik@klinik.se', 'sara@admin.se'), which leaks
a single credential for all accounts; fix by generating and using unique bcrypt
hashes per row (replacing the shared hash for 'anna@test.se', 'lars@test.se',
'erik@klinik.se', and 'sara@admin.se'), and either remove the ADMIN seed row for
'sara@admin.se' or set its is_active = false (and optionally set is_active =
false for the 'VET' row 'erik@klinik.se') so privileged accounts are not
immediately usable in dev/CI.

---

Nitpick comments:
In `@src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java`:
- Around line 46-51: The ERROR log currently emitted every run using
repository.countByRetryCountGreaterThanEqual(MAX_RETRIES) causes alert fatigue;
fix by recording a class-level long lastReportedPermanentFailures (default 0),
update a Micrometer gauge via
MeterRegistry.gauge("s3.orphaned.permanent_failures", ...) with the current
count, and only call log.warn/log.error (use WARN or ERROR per team policy) when
permanentFailures != lastReportedPermanentFailures or when permanentFailures
increases; update lastReportedPermanentFailures after logging so identical
repeated counts do not re-log.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dddf6509-b701-4845-a1ff-fbe961e9fae0

📥 Commits

Reviewing files that changed from the base of the PR and between 72c334c and 56102f9.

📒 Files selected for processing (6)
  • src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
  • src/main/java/org/example/vet1177/services/AttachmentService.java
  • src/main/java/org/example/vet1177/services/OrphanedS3CleanupWorker.java
  • src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
  • src/main/resources/application-dev.properties
  • src/main/resources/db/migration/dev/V3__insert_demo_data.sql
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/resources/application-dev.properties
  • src/main/java/org/example/vet1177/services/OrphanedS3Enqueuer.java
  • src/main/java/org/example/vet1177/repository/OrphanedS3ObjectRepository.java
  • src/main/java/org/example/vet1177/services/AttachmentService.java

@johanbriger
johanbriger merged commit dfed077 into main Apr 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Implement durable retry logic for failed S3/MinIO deletions

2 participants